Заказчик
Стартап: мобильное приложение по продаже продуктов питания.
Цель проекта
Необходимо проанализировать поведение покупателей на основании логов пользователей и результатов А/А/В - эксперимента (изменение шрифта во всем приложении).
Входные данные
logs_exp.csv
)EventName
— название события;
DeviceIDHash
— уникальный идентификатор пользователя;
EventTimestamp
— время события;
ExpId
— номер эксперимента: 246 и 247 — контрольные группы, а 248 — экспериментальная.
Ход исследования
Исследование пройдёт в четыре этапа:
import pandas as pd
import numpy as np
import matplotlib as mpl
import matplotlib.pyplot as plt
import seaborn as sns
import plotly.express as px
import warnings; warnings.filterwarnings(action = 'ignore')
from scipy import stats as st
from plotly import graph_objects as go
from IPython.display import set_matplotlib_formats
from pandas.plotting import register_matplotlib_converters
register_matplotlib_converters()
# снимаем ограничение на количество столбцов
pd.set_option('display.max_columns', None)
# снимаем ограничение на ширину столбцов
#pd.set_option('display.max_colwidth', None)
# игнорируем предупреждения
pd.set_option('chained_assignment', None)
# выставляем ограничение на показ знаков после запятой
pd.options.display.float_format = '{:,.2f}'.format
# устанавливаем стиль графиков
sns.set_style('darkgrid')
sns.set(rc={'figure.dpi':200, 'savefig.dpi':300})
sns.set_context('notebook')
sns.set_style('ticks')
# чтение файлов с данными и сохранение в df
try:
data = pd.read_csv('/datasets/logs_exp.csv', sep='\t')
except:
data = pd.read_csv('logs_exp.csv', sep='\t')
data.info()
display(data.head(), data.sample(5), data.tail())
<class 'pandas.core.frame.DataFrame'> RangeIndex: 244126 entries, 0 to 244125 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 EventName 244126 non-null object 1 DeviceIDHash 244126 non-null int64 2 EventTimestamp 244126 non-null int64 3 ExpId 244126 non-null int64 dtypes: int64(3), object(1) memory usage: 7.5+ MB
EventName | DeviceIDHash | EventTimestamp | ExpId | |
---|---|---|---|---|
0 | MainScreenAppear | 4575588528974610257 | 1564029816 | 246 |
1 | MainScreenAppear | 7416695313311560658 | 1564053102 | 246 |
2 | PaymentScreenSuccessful | 3518123091307005509 | 1564054127 | 248 |
3 | CartScreenAppear | 3518123091307005509 | 1564054127 | 248 |
4 | PaymentScreenSuccessful | 6217807653094995999 | 1564055322 | 248 |
EventName | DeviceIDHash | EventTimestamp | ExpId | |
---|---|---|---|---|
10766 | MainScreenAppear | 1836871388568984876 | 1564649009 | 247 |
46451 | MainScreenAppear | 5807560156115311891 | 1564731912 | 246 |
14163 | CartScreenAppear | 2013880302311166256 | 1564655108 | 247 |
25748 | OffersScreenAppear | 8573758417293729771 | 1564672050 | 247 |
7049 | MainScreenAppear | 8822682017610729706 | 1564641298 | 247 |
EventName | DeviceIDHash | EventTimestamp | ExpId | |
---|---|---|---|---|
244121 | MainScreenAppear | 4599628364049201812 | 1565212345 | 247 |
244122 | MainScreenAppear | 5849806612437486590 | 1565212439 | 246 |
244123 | MainScreenAppear | 5746969938801999050 | 1565212483 | 246 |
244124 | MainScreenAppear | 5746969938801999050 | 1565212498 | 246 |
244125 | OffersScreenAppear | 5746969938801999050 | 1565212517 | 246 |
# проверка на количество пропущенных значений
data.isna().sum()
EventName 0 DeviceIDHash 0 EventTimestamp 0 ExpId 0 dtype: int64
# обзор данных
display(data['EventName'].unique())
display(data['ExpId'].unique())
array(['MainScreenAppear', 'PaymentScreenSuccessful', 'CartScreenAppear', 'OffersScreenAppear', 'Tutorial'], dtype=object)
array([246, 248, 247])
# проверка на явные дубликаты
display(data.duplicated().sum())
data.duplicated().sum() / len(data)
413
0.0016917493425526163
Комментарий:
Таблица содержит информацию о 224126-ти действиях пользователей или событий. Самих событий всего пять.
На первый взгляд, данные не содержат пропусков, но кое-что требует правки: изменение названий столбцов на более удобные, замена типа данных в столбце EventTimestamp (похоже, что дата и время указаны в секундах). Так же нужно избавиться от дубликатов и вынести дату в отдельную колонку для дальнейшего анализа.
Приступаем к предобработке данных.
# удалим дубликаты
data = data.drop_duplicates().reset_index(drop=True)
# переименуем столбцы:
data = data.rename(columns={'EventName': 'event', 'DeviceIDHash': 'user_id',
'EventTimestamp': 'date_time', 'ExpId': 'group_id'})
# заменим тип данных `datetime`:
data['date_time'] = pd.to_datetime(data['date_time'], unit='s')
# добавим поле с датой:
data['date'] = pd.to_datetime(data['date_time'].dt.date)
data.sample(5)
event | user_id | date_time | group_id | date | |
---|---|---|---|---|---|
103397 | MainScreenAppear | 3034063235370171931 | 2019-08-03 18:14:51 | 247 | 2019-08-03 |
168135 | CartScreenAppear | 9066529870075932190 | 2019-08-05 16:45:53 | 246 | 2019-08-05 |
116826 | MainScreenAppear | 3572316166490242010 | 2019-08-04 09:44:22 | 247 | 2019-08-04 |
97341 | MainScreenAppear | 2438520924615884865 | 2019-08-03 15:19:55 | 246 | 2019-08-03 |
13312 | MainScreenAppear | 2823790631517317353 | 2019-08-01 10:00:49 | 247 | 2019-08-01 |
Комментарий:
Итак, мы:
Но перед анализом необходимо проверить, что пользователи в группах не пересекаются. Это важно для чистоты эксперимента.
data.groupby('user_id', as_index=False).agg({'group_id':'nunique'}).query('group_id > 1')
user_id | group_id |
---|
Комментарий:
Пользователей, которые участвуют одновременно в двух или более группах - нет.
print('Всего событий: %d \nВидов событий %d \nВсего пользователей : %d' % (data.shape[0],
data['event'].nunique(),
data['user_id'].nunique()))
print('В среднем, событий на пользователя: %d' % (len(data) / data['user_id'].nunique()))
Всего событий: 243713 Видов событий 5 Всего пользователей : 7551 В среднем, событий на пользователя: 32
# посмотрим сколько событий в среднем приходится на пользователя
events_by_user = data.groupby('user_id', as_index=False).agg(event_count = ('event', 'count'))
display(events_by_user.event_count.describe())
plt.figure(figsize=(15, 5))
plt.title('Распрпеделение количества событий на пользователя', loc='center')
sns.histplot(events_by_user.event_count, bins=200)
plt.xlabel('Количество событий')
plt.ylabel('Количество пользователей')
plt.show()
count 7,551.00 mean 32.28 std 65.15 min 1.00 25% 9.00 50% 20.00 75% 37.00 max 2,307.00 Name: event_count, dtype: float64
Комментарий:
В среднем, на пользователя приходится порядка 32-х событий. При этои, минимальное количество - всего одно, а максимальное - 2307 шт. Если говорить о медиане - это 20 событий на пользователя.
data['date_time'].describe()
count 243713 unique 176654 top 2019-08-01 14:40:35 freq 9 first 2019-07-25 04:43:36 last 2019-08-07 21:15:17 Name: date_time, dtype: object
Данные содержат информацию с 25/07/2019 по 07/08/2019.
# гистограммы по дате и времени:
plt.title('Распределение логов по дате и времени', loc='center')
data['date_time'].hist(bins=100, xrot=15, figsize=(15, 5), alpha=0.8)
plt.show()
plt.title('Распределение логов по времени суток', loc='center')
data['date_time'].dt.hour.hist(bins=24, figsize=(15, 5), alpha=0.8)
plt.xticks(range(0, 23))
plt.show()
Комментарий:
На первой гистограмме видно, что данные за июль - неполные. Технически в логи новых дней по некоторым пользователям могут «доезжать» события из прошлого — это может «перекашивать данные». Поэтому, чтобы избежать данной проблемы, берем данные с 01-08-2019 по 07/08/2019. Так же, мы видим, что основная активность пользователей происходит в дневное время.
# Отбросим старые данные и посмотрим, сколько событий и пользователей мы потеряли.
before_logs = data.shape[0]
before_users = data.user_id.nunique()
print('ДО корректировки периода всего событий: %d, всего пользователей: %d.' % (before_logs,
before_users))
data = data[data['date_time'] >= '2019-08-01']
print()
print('ПОСЛЕ корректировки периода всего событий: %d, всего пользователей: %d.' % (data.shape[0],
data['user_id'].nunique()))
print()
# diff:
print('Изменение количества логов:',
data.shape[0]- before_logs,
'({:.1%})'.format((data.shape[0]-before_logs)/before_logs))
print('Изменение количества пользователей:',
data['user_id'].nunique()- before_users,
'({:.1%})'.format((data['user_id'].nunique()-before_users)/before_users))
ДО корректировки периода всего событий: 243713, всего пользователей: 7551. ПОСЛЕ корректировки периода всего событий: 240887, всего пользователей: 7534. Изменение количества логов: -2826 (-1.2%) Изменение количества пользователей: -17 (-0.2%)
# Теперь посмотрим на распределения в тестовых группах
# (по количеству уникальных пользователей и по количеству событий):
display(data.groupby('group_id', as_index=False).agg({'user_id': 'nunique'}),
data.groupby('group_id', as_index=False).agg(event_count = ('user_id', 'count')))
group_id | user_id | |
---|---|---|
0 | 246 | 2484 |
1 | 247 | 2513 |
2 | 248 | 2537 |
group_id | event_count | |
---|---|---|
0 | 246 | 79302 |
1 | 247 | 77022 |
2 | 248 | 84563 |
Комментарий:
Количество пользователей в группах в общем и целом сопоставимо, количество событий немного разнится. Группа 248 имеет больше всего пользователей и событий.
Посмотрим, какие события есть в логах, как часто они встречаются.
events = (data.
groupby('event', as_index=False).
agg({'user_id': 'count'}).
rename(columns={'user_id' : 'total_events'}).
sort_values(by='total_events', ascending=False))
display(events)
plt.figure(figsize=(15, 5))
ax = sns.color_palette("flare", as_cmap=True)
ax = sns.barplot(x='total_events', y='event', data=events)
ax.set_title('Частота событий в логах')
ax.set_xlabel('Количество')
ax.set_ylabel('')
plt.show()
event | total_events | |
---|---|---|
1 | MainScreenAppear | 117328 |
2 | OffersScreenAppear | 46333 |
0 | CartScreenAppear | 42303 |
3 | PaymentScreenSuccessful | 33918 |
4 | Tutorial | 1005 |
Комментарий:
# Отсортируем события по числу пользователей.
# Посчитаем долю пользователей, которые хоть раз совершали событие.
funnel = (data.
groupby('event', as_index=False).
agg({'user_id': 'nunique'}).
rename(columns={'user_id' : 'total_users'}).
sort_values(by='total_users', ascending=False))
funnel['percent'] = round(funnel['total_users'] / data['user_id'].nunique() * 100, 2)
funnel
event | total_users | percent | |
---|---|---|---|
1 | MainScreenAppear | 7419 | 98.47 |
2 | OffersScreenAppear | 4593 | 60.96 |
0 | CartScreenAppear | 3734 | 49.56 |
3 | PaymentScreenSuccessful | 3539 | 46.97 |
4 | Tutorial | 840 | 11.15 |
Комментарий:
Мы видим, что все события выстраиваются в цепочку действий, кроме просмотра урока (Tutorial). Для того, чтобы посмотреть урок (как пользоваться приложением?) не обязательно нужно что-то купить. Так же, чаще всего, интерфейс является интуитивно понятным. Поэтому шаг Tutorial из дальнейшей цепочки уберем.
Тогда последовательность действий видится такой:
Мы определились с последовательносью действий для воронки событий. Посчитаем, какая доля пользователей проходит на следующий шаг воронки (от числа пользователей на предыдущем). То есть для последовательности событий A → B → C → D посчитаем отношение числа пользователей с событием B к количеству пользователей с событием A, отношение числа пользователей с событием C к количеству пользователей с событием B и т.д.
# считае % по шагам (от предыдущего шага)
users = data.pivot_table(
index='user_id',
columns='event',
values='date_time',
aggfunc='min')
print('Посетителей всего:',
'({:.1%})'.format(users['MainScreenAppear'].count() / users['MainScreenAppear'].count() ))
print('Просмотрели Каталог в % от предыдущего шага:',
'({:.1%})'.format(users['OffersScreenAppear'].count() / users['MainScreenAppear'].count() ))
print('Просмотрели Карточку товара в % от предыдущего шага:',
'({:.1%})'.format(users['CartScreenAppear'].count() / users['OffersScreenAppear'].count()))
print('Оплатили в % от предыдущего шага:',
'({:.1%})'.format(users['PaymentScreenSuccessful'].count() / users['CartScreenAppear'].count()))
Посетителей всего: (100.0%) Просмотрели Каталог в % от предыдущего шага: (61.9%) Просмотрели Карточку товара в % от предыдущего шага: (81.3%) Оплатили в % от предыдущего шага: (94.8%)
funnel = funnel[funnel['event'] != 'Tutorial']
fig = go.Figure(go.Funnel(y = funnel['event'],
x = funnel['total_users'],
opacity = 0.8,
textposition = 'inside',
textinfo = 'value + percent previous'))
fig.update_layout(title_text='Воронка событий')
fig.layout.template = 'plotly_white'
fig.show()
Комментарий:
Мы видим, что наибольшее количество пользователей теряется после первого шага (более 38%). И только 48% от первоначального количества пользователей успешно оплачивают товары из корзины.
Мы знаем, что у нас есть 2 контрольные группы для А/А-эксперимента (246 и 247), чтобы проверить корректность всех механизмов и расчётов, и одна тестовая группа В (248).
Посмотрим на количество участников в каждой группе.
data.groupby('group_id', as_index=False).agg(count=('user_id', 'nunique'))
group_id | count | |
---|---|---|
0 | 246 | 2484 |
1 | 247 | 2513 |
2 | 248 | 2537 |
funnel_group = (data.
groupby(['event', 'group_id'], as_index=False).
agg({'user_id': 'nunique'}).
rename(columns={'user_id' : 'total_users'}).
sort_values(by=['group_id','total_users'], ascending=False))
funnel_group = funnel_group[funnel_group['event'] != 'Tutorial']
funnel_group.style.background_gradient(sns.light_palette("brown", as_cmap=True))
event | group_id | total_users | |
---|---|---|---|
5 | MainScreenAppear | 248 | 2493 |
8 | OffersScreenAppear | 248 | 1531 |
2 | CartScreenAppear | 248 | 1230 |
11 | PaymentScreenSuccessful | 248 | 1181 |
4 | MainScreenAppear | 247 | 2476 |
7 | OffersScreenAppear | 247 | 1520 |
1 | CartScreenAppear | 247 | 1238 |
10 | PaymentScreenSuccessful | 247 | 1158 |
3 | MainScreenAppear | 246 | 2450 |
6 | OffersScreenAppear | 246 | 1542 |
0 | CartScreenAppear | 246 | 1266 |
9 | PaymentScreenSuccessful | 246 | 1200 |
fig = go.Figure()
fig.add_trace(go.Funnel(name = '246',
y = funnel_group.query('group_id == 246')['event'],
x = funnel_group.query('group_id == 246')['total_users'],
opacity = 0.8,
textposition = 'inside',
textinfo = 'value + percent previous'))
fig.add_trace(go.Funnel(name = '247',
y = funnel_group.query('group_id == 247')['event'],
x = funnel_group.query('group_id == 247')['total_users'],
opacity = 0.8,
textposition = 'inside',
textinfo = 'value + percent previous'))
fig.add_trace(go.Funnel(name = '248',
y = funnel_group.query('group_id == 248')['event'],
x = funnel_group.query('group_id == 248')['total_users'],
opacity = 0.8,
textposition = 'inside',
textinfo = 'value + percent previous'))
fig.update_layout(title_text='Воронка событий в разрезе тестовых групп')
fig.layout.template = 'plotly_white'
fig.show()
Комментарий:
Внешне, группы очень похожи. В А/В-тестировании проверяем гипотезу о равенстве выборок, но сначала проверим находят ли статистические критерии разницу между выборками 246 и 247 (А/А-тест).Используем Z-критерий (статистический тест, позволяющий определить, различаются ли два средних значения генеральной совокупности, когда дисперсии известны и размер выборки велик). Для удобства, напишем функцию.
def z_test(df1, df2, event, alpha, n):
'''
Функция принимает на вход два датафрейма с логами и по заданному событию попарно проверяет
есть ли статистически значимая разница между долями пользователей, совершивших его в группе 1 и группе 2.
Входные параметры:
- df1, df2 - датафреймы с логами
- event - событие
- alpfa - критический уровень статистической значимости
- n - поправка Боннферони для критического уровня статистической значимости
'''
# критический уровень статистической значимости c поправкой Бонферрони
bonferroni_alpha = alpha / n
# число пользователей в группе 1 и группе 2:
n_users = np.array([df1['user_id'].nunique(),
df2['user_id'].nunique()])
# число пользователей, совершивших событие в группе 1 и группе 2
success = np.array([df1[df1['event'] == event]['user_id'].nunique(),
df2[df2['event'] == event]['user_id'].nunique()])
# пропорции успехов в группах:
p1 = success[0] / n_users[0]
p2 = success[1] / n_users[1]
# пропорция успехов в комбинированном датасете:
p_combined = (success[0] + success[1]) / (n_users[0] + n_users[1])
# разница пропорций в датасетах
difference = p1 - p2
# считаем статистику в ст.отклонениях стандартного нормального распределения
z_value = difference / np.sqrt(p_combined * (1 - p_combined) * (1/n_users[0] + 1/n_users[1]))
# задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
distr = st.norm(0, 1)
p_value = (1 - distr.cdf(abs(z_value))) * 2 #тест двусторонний, удваиваем результат
print('Событие:', event)
print('p-значение: ', p_value)
if p_value < bonferroni_alpha:
print('Отвергаем нулевую гипотезу: между долями есть разница')
else:
print(
'Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными')
Согласно предложенному процессу, нам нужно будет сопоставить доли по каждому событию между:
Всего у нас 4 вида событий, 4 A/A теста и 12 А/В, следовательно для всех тестов мы вводим поправку Бонферрони bonferroni_alpha = alpha / 4
или bonferroni_alpha = alpha / 12
, чтобы застраховать себя от ложного результата.
Введем основную и альтернативные гипотезы для всех попарных сравнений:
$\begin{equation*} \begin{cases} H_0 :\text{доли уникальных посетителей, побывавших на этапе воронки, одинаковы}\\ H_1 :\text{доли уникальных посетителей, побывавших на этапе воронки, отличаются} \end{cases} \end{equation*}$
# проверим, есть ли статистически значимая разница между контрольными группами 246 и 247:
for event in funnel_group['event'].unique():
z_test(data[data['group_id'] == 246], data[data['group_id'] == 247], event,.05, 4)
print()
Событие: MainScreenAppear p-значение: 0.7570597232046099 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Событие: OffersScreenAppear p-значение: 0.2480954578522181 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Событие: CartScreenAppear p-значение: 0.22883372237997213 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Событие: PaymentScreenSuccessful p-значение: 0.11456679313141849 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
Комментарий:
Главная цель А/А-теста — показать, можно ли доверять результатам эксперимента, который будет запущен в тех же условиях, но уже с разными вариантами шрифта. Если в ходе А/А-теста победителя выявить не удалось, можно запускать А/B-тест.
Между группами 246 и 247 ни по одному событию нет статистически достоверного отличия при заданном уровне значимости, а значит, приступаем к A/B-тестированию.
# проверим, есть ли статистически значимая разница между контрольными группами 246 и 248:
for event in funnel_group['event'].unique():
z_test(data[data['group_id'] == 246], data[data['group_id'] == 248], event, .05, 12)
print()
Событие: MainScreenAppear p-значение: 0.2949721933554552 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Событие: OffersScreenAppear p-значение: 0.20836205402738917 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Событие: CartScreenAppear p-значение: 0.07842923237520116 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Событие: PaymentScreenSuccessful p-значение: 0.2122553275697796 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
Комментарий:
При заданном уровне значимости у нас нет оснований считать группы 246 и 248 разными.
# проверим, есть ли статистически значимая разница между контрольными группами 247 и 248:
for event in funnel_group['event'].unique():
z_test(data[data['group_id'] == 247], data[data['group_id'] == 248], event,.05, 12)
print()
Событие: MainScreenAppear p-значение: 0.4587053616621515 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Событие: OffersScreenAppear p-значение: 0.9197817830592261 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Событие: CartScreenAppear p-значение: 0.5786197879539783 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Событие: PaymentScreenSuccessful p-значение: 0.7373415053803964 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
Комментарий:
При заданном уровне значимости различия между группами 247 и 248 не обнаружились.
# проверим есть ли статистически значимая разница между объединённой контрольной и экпериментальной 248 группами:
for event in funnel_group['event'].unique():
z_test(data[data['group_id'] != 248], data[data['group_id'] == 248], event,.05,12)
print()
Событие: MainScreenAppear p-значение: 0.29424526837179577 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Событие: OffersScreenAppear p-значение: 0.43425549655188256 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Событие: CartScreenAppear p-значение: 0.18175875284404386 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Событие: PaymentScreenSuccessful p-значение: 0.6004294282308704 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
Комментарий:
Аналогичный финал проверки гипотез по последним парам. ___ Фиксируем, что изменение шрифтов во всём приложении на поведение пользователей не повлияло.
Было выявлено, что:
Еще одно событие (Tutorial) было исключено из анализа ввиду необязательного прохождения и отсутствия влияния на остальные шаги.
БОльшее количество пользователей приложение теряло после первого шага (более 38%), чуть менее 9% на следующем и около 2% при переходе на последний шаг. Т.е. примерно 48% пользователей воспользовались приложением и оплатили заказ. ______ Далее, был проанализирован результат А/А/В-эксперимента(изменение шрифта во всем приложении), для этого были ипользованы логи событий за неделю (с 01/08/2019 по 07/08/2019).
В эксперименте были учтено поведение пользователей, разделенных на три группы:
Согласно предложенному процессу, нам нужно было сопоставить доли пользователей по каждому событию между:
Множесто А/В-тестов, проведённых по каждому из событий, не обнаружили статистически значимой разницы между группами. Т.е. изменение шрифтов во всём приложении на поведение пользователей не повлияло.
# Циклом
funnel['cycle'] = 0
for i in range(0, len(funnel['percent'])):
if i == 0:
funnel['cycle'].iloc[i] = 100
else:
funnel['cycle'].iloc[i] = round(int(funnel['total_users'].iloc[i]) / int(funnel['total_users'].iloc[i-1]), 2) * 100
funnel
event | total_users | percent | cycle | |
---|---|---|---|---|
1 | MainScreenAppear | 7419 | 98.47 | 100 |
2 | OffersScreenAppear | 4593 | 60.96 | 62 |
0 | CartScreenAppear | 3734 | 49.56 | 81 |
3 | PaymentScreenSuccessful | 3539 | 46.97 | 95 |
# Методом shift
funnel['shift'] = (round(funnel['total_users'] / funnel.shift(1)['total_users'], 2) * 100).fillna(100)
funnel
event | total_users | percent | cycle | shift | |
---|---|---|---|---|---|
1 | MainScreenAppear | 7419 | 98.47 | 100 | 100.00 |
2 | OffersScreenAppear | 4593 | 60.96 | 62 | 62.00 |
0 | CartScreenAppear | 3734 | 49.56 | 81 | 81.00 |
3 | PaymentScreenSuccessful | 3539 | 46.97 | 95 | 95.00 |
По ссылке отличное видео про применение оконок в пандасе. Про шифт тоже есть
https://www.youtube.com/watch?v=yQ7qHZBY5xI&t=1s&ab_channel=karpov.courses
2.1Переделать шаг проверки гипотез:
Сначала подготовить данные, по которым будет идти проверка (сводная таблица с количеством пользователей совершивших каждое из событий)
funnel_hypothesis = data.pivot_table(index='event', columns='group_id', \
values='user_id',aggfunc='nunique',
margins=True) \
.reset_index().sort_values('All', ascending=False) \
.reset_index(drop=True)
funnel_hypothesis = funnel_hypothesis.reindex([1, 2, 3, 4, 0]).reset_index(drop=True)
funnel_hypothesis['246/247'] = funnel_hypothesis[246] + funnel_hypothesis[247]
funnel_hypothesis
group_id | event | 246 | 247 | 248 | All | 246/247 |
---|---|---|---|---|---|---|
0 | MainScreenAppear | 2450 | 2476 | 2493 | 7419 | 4926 |
1 | OffersScreenAppear | 1542 | 1520 | 1531 | 4593 | 3062 |
2 | CartScreenAppear | 1266 | 1238 | 1230 | 3734 | 2504 |
3 | PaymentScreenSuccessful | 1200 | 1158 | 1181 | 3539 | 2358 |
4 | All | 2484 | 2513 | 2537 | 7534 | 4997 |
(в теле функции не должно быть ни цикла, ни работы с датафреймом, только мат. вычисления для стат. теста)
def z_test_return(x1, x2, y1, y2):
p1 = x1/y1
p2 = x2/y2
p_combined = (x1 + x2) / (y1 + y2)
difference = p1 - p2
z_value = difference / (p_combined * (1 - p_combined) * (1/y1 + 1/y2)) ** 0.5
distr = st.norm(0, 1)
p_value = (1 - distr.cdf(abs(z_value))) * 2
return p_value
# Группы 246-247
alpha = 0.05
for i in range(0, 4):
for j in range(1, 2):
if j < 3:
k = j + 1
else:
k = 1
p_value = z_test_return(funnel_hypothesis.iloc[i, j], funnel_hypothesis.iloc[i, k],
funnel_hypothesis.iloc[4, j], funnel_hypothesis.iloc[4, k])
print('Событие:', funnel_hypothesis.event.unique()[i])
print('p-значение:', p_value)
if p_value < alpha:
print('Отвергаем нулевую гипотезу: между долями есть разница')
else:
print('Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными')
print('--------------------------------------')
Событие: MainScreenAppear p-значение: 0.7570597232046099 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными -------------------------------------- Событие: OffersScreenAppear p-значение: 0.2480954578522181 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными -------------------------------------- Событие: CartScreenAppear p-значение: 0.22883372237997213 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными -------------------------------------- Событие: PaymentScreenSuccessful p-значение: 0.11456679313141849 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными --------------------------------------
# Группы 247-248
alpha = 0.05
for i in range(0, 4):
for j in range(2, 3):
if j < 3:
k = j + 1
else:
k = 1
p_value = z_test_return(funnel_hypothesis.iloc[i, j], funnel_hypothesis.iloc[i, k],
funnel_hypothesis.iloc[4, j], funnel_hypothesis.iloc[4, k])
print('Событие:', funnel_hypothesis.event.unique()[i])
print('p-значение:', p_value)
if p_value < alpha:
print('Отвергаем нулевую гипотезу: между долями есть разница')
else:
print('Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными')
print('--------------------------------------')
Событие: MainScreenAppear p-значение: 0.4587053616621515 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными -------------------------------------- Событие: OffersScreenAppear p-значение: 0.9197817830592261 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными -------------------------------------- Событие: CartScreenAppear p-значение: 0.5786197879539783 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными -------------------------------------- Событие: PaymentScreenSuccessful p-значение: 0.7373415053803964 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными --------------------------------------
# Группы 248-246
alpha = 0.05
for i in range(0, 4):
for j in range(3, 4):
if j < 3:
k = j + 1
else:
k = 1
p_value = z_test_return(funnel_hypothesis.iloc[i, j], funnel_hypothesis.iloc[i, k],
funnel_hypothesis.iloc[4, j], funnel_hypothesis.iloc[4, k])
print('Событие:', funnel_hypothesis.event.unique()[i])
print('p-значение:', p_value)
if p_value < alpha:
print('Отвергаем нулевую гипотезу: между долями есть разница')
else:
print('Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными')
print('--------------------------------------')
Событие: MainScreenAppear p-значение: 0.2949721933554552 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными -------------------------------------- Событие: OffersScreenAppear p-значение: 0.20836205402738917 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными -------------------------------------- Событие: CartScreenAppear p-значение: 0.07842923237520116 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными -------------------------------------- Событие: PaymentScreenSuccessful p-значение: 0.2122553275697796 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными --------------------------------------
# Группы 246/247-248
alpha = 0.05
for i in range(0, 4):
for j in range(4, 5):
if j < 3:
k = j + 1
else:
k = 1
p_value = z_test_return(funnel_hypothesis.iloc[i, 3], funnel_hypothesis.iloc[i, 5],
funnel_hypothesis.iloc[4, 3], funnel_hypothesis.iloc[4, 5])
print('Событие:', funnel_hypothesis.event.unique()[i])
print('p-значение:', p_value)
if p_value < alpha:
print('Отвергаем нулевую гипотезу: между долями есть разница')
else:
print('Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными')
print('--------------------------------------')
Событие: MainScreenAppear p-значение: 0.29424526837179577 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными -------------------------------------- Событие: OffersScreenAppear p-значение: 0.43425549655188256 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными -------------------------------------- Событие: CartScreenAppear p-значение: 0.18175875284404386 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными -------------------------------------- Событие: PaymentScreenSuccessful p-значение: 0.6004294282308704 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными --------------------------------------
Ну я думаю, чтобы определить допустимую вероятность ложноположительного результата хотя бы в одном из 16 тестов, мы можем использовать формулу вероятности обратного события:
$$P( \overline A) = 1 - P(A)$$P(хотя бы один ложноположительный результат) = 1 - P(ни одного ложноположительного результата)
print(f'Вероятность того, что не будет ни одного ложноположительного результата при 16 тестах, равна: {round(((1 - 0.05)**16) * 100, 1)}%')
print(f'Вероятность того, что хотя бы один тест даст ложноположительный результат, равна: {round((1 - (1 - 0.05)**16) * 100, 1)}%')
Вероятность того, что не будет ни одного ложноположительного результата при 16 тестах, равна: 44.0% Вероятность того, что хотя бы один тест даст ложноположительный результат, равна: 56.0%
Если есть желание поглубже познакомиться со статистикой, тервером и аб тестами (при этом глубоко не погружаясь в математические дебри) рекомендую посмотреть цикл лекций Глеба Михайлова (возможно он наставник у тебя)
https://www.youtube.com/playlist?list=PLQJ7ptkRY-xbHLLI66KdscKp_FJt0FsIi
И отличная статья про структуры данных в пандас
http://datalytics.ru/all/uglublennoe-izuchenie-pandas-struktury-dannyh/